系列背景:我在用 Rust 打造私有、自持的 AI agent 網格 spectyn-mesh——
單一 binary、CLI / TUI / App / HTTP 多介面、本地與雲端模型混編、多機聯邦。
前五天都在打 CLI。今天操作者換了方向:「目前我是希望這幾天能把單機版的 app 都先跑起來,全生命週期都弄好,
先弄 mac 再弄 window。反正你目前先好好的一步一步把 mac 版本搞好」要回答的是另一個問題:一個沒裝過 Rust、沒開過終端機的人,雙擊一個圖示,
能不能得到同一套東西?

這一天的所有缺陷有一個共同形狀,而且它不是「功能還沒寫」。是某個地方
悄悄說了不真的話:設定檔宣告了不存在的檔案、exit 0 卻什麼都沒做、doctor 說沒有身分而身分就在那裡、測試綠得毫無理由。
母規則同前五天:任何檢查必須先示範它會紅才算數。綠燈不是證據。
「app 跑起來了」沒辦法判對錯。第一件事是把它拆成九關,每關都要能單獨
說出「紅長什麼樣」——L1 建置成 .app/.dmg、L2 bundle 完整性、
L3 sidecar 解析、L4 首次啟動、L5 前端連 daemon、L6 導入 LLM、
L7 終端介面、L8 退出收乾淨、L9 安裝形式。
寫完清單,盤點現況:

tauri.conf.json 裡宣告了兩個 sidecar,而 src-tauri/binaries/ 是空的。
前端 dist/ 未建,target/release/bundle/ 不存在——從來沒有人跑過一次
完整的 tauri build。連負責備料的 copy-sidecar.sh 都沒有執行位元
(mode 100644,exec bit 從來沒被 commit 進去)。
這不是「還沒做」,是宣告與實體脫鉤。設定檔說會帶兩個 binary 進去,
打包時會直接失敗,而在此之前沒有任何一條檢查會告訴你——因為那條路沒人走過。
第一天的教訓在這裡又出現一次:沒有跑過的路,不算路。
備完料,L1 一次過,L2 也過。進 L4:用沙箱 HOME 模擬乾淨機器,把 PATH 上的spectyn 剝掉,直接跑 bundle 裡的執行檔,看它會不會自己把 daemon 拉起來。

一秒就沒了。沒有 stdout,沒有 stderr,沒有 crash report。前景再跑一次取
退出碼:exit=0。
成功而且一個字都不說,是最會騙人的一種訊號。 腳本會判它成功,人會判它
閃退,兩邊都錯。真相不在退出碼裡,在 ps 裡:/Applications 底下早就裝著
一份舊版而且正在跑,tauri-plugin-single-instance 偵測到已有實例,
就讓新起的那個安靜退場。
我這份新 build 沒問題,是我測的方式撞到了鎖。而這件事本身就是 L9 的預告:
桌面 app 的「安裝」不只是複製檔案,既有安裝會回頭影響你怎麼測新的。
操作者接著問:「你看看有啥方法能實際測試整個 app,感覺功能很不完整。」
翻下去發現專案裡本來就有一套原生視窗 E2E(WebDriver 直接驅動 Tauri 視窗),
但它壞了很久沒人跑。救活它之後,第一次跑就出事:

首屏顯示「Signed in as …(google)」——它在操作者的真實狀態上跑。
第一次修:讓測試帶著沙箱 HOME 啟動,首屏果然變回乾淨機器的樣子。
然後第二次跑,「首次啟動」那組斷言又紅了。原因是 macOS 的 WKWebView
不看 $HOME,它用 NSHomeDirectory()(讀 passwd 條目),所以擦掉 HOME
沒有把 localStorage 擦掉。
這也修正了我先前的一個判斷:我原本以為測試污染到了使用者已安裝的那份 app,mtime 證明沒有——兩個儲存區是分開的,要擦的是 debug 自己那份。
值得記一筆的是這個 bug 的方向:殘留狀態讓斷言變紅,所以被抓到了。
如果當初斷言寫成反向(例如「不該出現登入牆」),同一個殘留會讓它靜靜地一直綠。
修完之後走查 18 條斷言、28 次判定全數 PASS。母規則照做:把 vite 殺掉再跑一次,
整片倒下,不是靜默通過。
操作者:「ollama、vllm,或這類本地的可能也能自動偵測跟串接。」

盤點比想像難看:Ollama 有 480 處引用,vLLM 只有 1 處——而且只是一個字串。
但真正的斷點不在偵測,在串接。三個獨立的洞,任何一個都足以讓「偵測到的
本地引擎」變成擺設:
洞 1:埠號被當成身分。 任何在 11434 上答話的東西都被叫做 Ollama。
改成證據式辨識——問它自己是誰,再依回答決定 provider type。
洞 2:onboarding 只認 ollama。 偵測器回報了 vLLM,設定檔照樣不寫它。
改成候選表當唯一真相,偵測得到的一律能寫進去。
洞 3:寫進設定檔的位址,沒有人讀。 provider_base_url 只看環境變數與
內建預設,設定檔裡的 url 從來不在解析鏈上——寫得進去,讀不出來。
操作者:「spectyn cli 有辦法透過 computer use 或其他的方式,實際的測試
terminal 上面運作的表現嗎?」
可以,但那是最差的選項:慢、不穩,而且看到的是像素。終端有更硬的測法。

src/tui.rs 有 10,687 行、125 個測試,用的是 ratatui 的 TestBackend——
那測的是 widget 樹,看不到真實終端層做的任何事,因為 TestBackend 就是
拿來取代那一層的。管線式的 Command::output() 同樣看不到:管線不是終端,
binary 會走另一條分支,測到的是沒出貨的那條。
所以開一個真的偽終端(PTY),把出貨的 binary 丟進去跑,輸出餵進 VT100
模擬器,斷言「螢幕上第幾列印了什麼」。六條斷言涵蓋:有沒有進 alternate
screen、離開時有沒有還回去、管線輸出該不該關色碼、提示符究竟有沒有被畫出來。
最後兩條是一對,缺一不可:第一條保證管線輸出不夾 ANSI,第二條保證 PTY 上的
說明內容相同——否則第一條守的是一條沒人走的路。
順帶抓到一個真 bug:doctor 在身分明明存在的機器上謊報「沒有身分」。
到這裡為止,今天修的每一個問題形狀都相同。既然如此,就該問下去。

開了一個 16 路稽核,每一路對應今天實際踩到的一種缺陷。每一路的發現再交給
一組獨立的懷疑者,任務是駁倒它——預設 refuted=true,只有能說出
「哪個真實操作者會怎麼被害」才算成立。
駁回率 28%。這個數字比成立數更重要:它代表懷疑者真的在擋假陽性,
而不是橡皮圖章。
稽核第一件事就是打我的臉:app crate 在這個分支上編不過——我早上給一個
struct 加欄位時只修了 core 端,漏掉 app 那個建構點。我自己沒發現,
因為我只跑 cargo build --bin spectyn,從來沒跑過 app crate。
更狠的是另一條:三處手抄的免金鑰清單都漏了 llamacpp。三處各自都是對的,
湊在一起就是壞的——一個偵測到的 vLLM 會被本專案自己的寫檔器寫進設定,
再被本專案自己的 runtime 以「沒有金鑰」跳過;而預設模型解析會落到 _ 分支,
把一個 Anthropic 的模型 id 寫成 localhost 伺服器的預設模型。
也就是說,我早上那個「本地引擎自動偵測與串接」的修法,會產出一份跑不起來的
設定。而沒有任何一個測試同時看著這三處。
改成單一判定 is_local_keyless() 四處共用,並用候選表本身當測試資料:
「這個偵測器能回報的每一個引擎,都必須被判定為免金鑰」。新增引擎不可能再漏。

core/target 有 55 GB、729,755 個檔案。清完剩 13 GB / 24,188 檔。cargo 的 no-op 從 24.2 秒掉到 10.5 秒,lib 測試從 84.8 秒掉到 9.9 秒。
積在裡面的是 636,449 個 .rcgu.o、一個 crate 的 23 組 hash(22 組是死的)、
26 GB 的 incremental,還有 7,295 個改名前留下的產物。每次 cargo 呼叫——
包括什麼都沒改的 no-op——都要 stat 過這一堆。
今天跑了幾十次 cargo,光這個稅就是十幾分鐘。
exit 0 不等於做了事。$HOME 擦不掉 WKWebView。以下是當天實錄的前半段,原文按時序未改。
app/src-tauri/tauri.conf.json 裡寫著:
"externalBin": ["binaries/spectyn-mesh", "binaries/spectyn"]
兩個 sidecar。而 app/src-tauri/binaries/ 是空的。
這不是「還沒做」,是宣告與實體脫鉤——設定檔說會帶兩個 binary 進去,
目錄裡一個都沒有。Tauri 打包時會直接失敗。而在此之前,沒有任何一條檢查
會告訴你這件事,因為從來沒有人跑過一次完整的 tauri build。
第一天的教訓在這裡又出現一次:沒有跑過的路,不算路。
盤點結果:
| 環節 | 狀態 |
|---|---|
core release 雙 binary |
只有 spectyn,缺 spectyn-mesh |
src-tauri/binaries/ |
空 |
前端 dist/ |
未建 |
target/release/bundle/ |
不存在 —— 從未打包過 |
備料腳本 app/scripts/copy-sidecar.sh 是存在的,還寫了很體貼的註解
(W31 那次:spectyn 根本沒被列進 externalBin,打包出來的 app 不帶 CLI,
終端機裡的 /agent spectyn 就默默退回去找使用者 PATH 上的 spectyn——
乾淨機器上那是空的)。
但它跑不起來:
$ ./scripts/copy-sidecar.sh release
permission denied: ./scripts/copy-sidecar.sh
git ls-files -s 給出答案:100644。執行位元從來沒被 commit 進去。
這比「腳本有 bug」更能說明事情:一個打包流程必經的腳本,在版本控制裡
是不可執行的狀態,代表沒有任何一次成功的打包經過它。修法一行,
但要連 git 的模式一起改,不然下次 clone 又是同一個坑:
chmod +x scripts/copy-sidecar.sh
git update-index --chmod=+x scripts/copy-sidecar.sh
跑起來之後,備料本身很順:
Copied core/target/release/spectyn-mesh -> src-tauri/binaries/spectyn-mesh-aarch64-apple-darwin
Copied core/target/release/spectyn -> src-tauri/binaries/spectyn-aarch64-apple-darwin
Tauri 要求 sidecar 檔名帶 target triple 後綴,腳本用 uname -s/uname -m
推出 aarch64-apple-darwin。這個細節等到做 Windows 版會再出現一次。
「app 跑起來了」是一句沒辦法判對錯的話。先把它拆成九關,每關都要能單獨
說出「紅」長什麼樣:
| 關 | 內容 | 紅的樣子 |
|---|---|---|
| L1 | 從原始碼建到 .app / .dmg |
打包指令非零退出 |
| L2 | bundle 完整性:三個 binary、架構、identifier、簽章 | 少一個 binary,或架構不符 |
| L3 | sidecar 解析:app 找到 bundle 內的 daemon,不是掉回 PATH | 解析到 /usr/local/bin 或裸名 |
| L4 | 首次啟動:乾淨 HOME、無既存 daemon → app 自己拉起來 | 沒有子行程,或健康檢查逾時 |
| L5 | 前端連上 daemon | 頁面空殼、/health 不通 |
| L6 | 導入 LLM:設 provider key → 能對話 | key 存不進去,或請求無回應 |
| L7 | 終端機介面:app 裡跑得動 spectyn |
PTY 不通,或跑到系統上的別支 |
| L8 | 退出:daemon 收乾淨 | 留孤兒行程佔著埠 |
| L9 | 安裝形式:.dmg → 拖進 Applications → 從那裡啟動 |
Gatekeeper 擋、quarantine 標記 |
L1 一次過。L2 也過:
Spectyn Mesh.app 100M Spectyn Mesh_0.6.0_aarch64.dmg 45M
Contents/MacOS/spectyn-mesh-app 44M Mach-O arm64
Contents/MacOS/spectyn-mesh 18M Mach-O arm64
Contents/MacOS/spectyn 35M Mach-O arm64
CFBundleIdentifier = ai.spectynmesh.app 版本 0.6.0 簽章 adhoc
注意 Contents/MacOS/ 裡的檔名是 spectyn-mesh,沒有 triple 後綴——
備料時明明叫 spectyn-mesh-aarch64-apple-darwin,Tauri 打包時把後綴脫掉了。
而 daemon.rs 的搜尋順序是「先找帶後綴的、再找裸名」。讀起來 fallback 有接住,
但讀不算數,L3 要實際跑一次才知道。
打包出乎意料地順:
Finished `release` profile [optimized] target(s) in 3m 44s
Bundling Spectyn Mesh.app
Bundling Spectyn Mesh_0.6.0_aarch64.dmg
Finished 2 bundles
於是進 L4:用沙箱 HOME 模擬乾淨機器,把 PATH 上的 spectyn 剝掉,直接跑
bundle 裡的執行檔,看它會不會自己把 daemon 拉起來。
app pid=2924
[1s] app 提早退出
一秒。沒有 stdout,沒有 stderr,沒有 crash report。前景再跑一次取退出碼:
exit=0
exit 0 而且一個字都不說,這是最會騙人的一種訊號——腳本會判它成功,
人會判它「閃退」,兩邊都錯。
真相在 ps 裡:
2655 /Applications/Spectyn Mesh.app/Contents/MacOS/spectyn-mesh-app
/Applications 裡早就裝著一份舊版而且正在跑。tauri-plugin-single-instance
偵測到已有實例,就讓新起的那個安靜退場。我這份新 build 沒問題,是我測的方式
撞到了單一實例鎖。
這件事本身也是 L9 的預告:桌面 app 的「安裝」不是複製檔案而已,
既有安裝會回頭影響你怎麼測新的。
追 L3 的時候讀 lib.rs,發現一件跟直覺相反的事:
// Start in-process SpectynMeshRuntime
match RuntimeState::init(rt_config, port).await { ... }
桌面 app 沒有去執行 Contents/MacOS/spectyn-mesh 這個 sidecar,
它把整個 runtime 跑在自己的行程裡。sidecar 只服務 start_daemon /stop_daemon 這組指令——而編譯警告正好說了:
warning: function `stop_daemon` is never used
warning: function `check_for_updates` is never used
warning: function `install_update` is never used
也就是說,bundle 裡那 18MB 的 daemon binary,正常啟動路徑一次都不會碰。
它不是沒用(CLI 使用者、start_daemon 指令會用),但「app 靠 sidecar 起 daemon」
這個我原本以為的模型是錯的。L3 的題目因此要重寫。
操作者問了一句很準的話:「你看看有啥方法能實際測試整個 app,感覺功能很不完整」。
庫裡其實有答案:app/scripts/run-native-e2e.sh 會起 vite、起 tauri-wd
橋接,然後開出真的 WKWebView 原生視窗用 WebDriver 驅動它。這是唯一
會碰到「真 app」的測試——playwright 那 32/32 測的是瀏覽器裡的網頁版。
它從來沒跑過。四個死結,環環相扣:
tests/wdio/native-window.mjs 與 wdio.conf.mjs 都指向 phantom-mesh-appnode_modules/.bin/wdio,但實際跑的是node tests/wdio/native-window.mjs,只 import webdriverio 模組。@wdio/local-runner 現在裝不起來:expect-webdriverio 拉@vitest/snapshot@4.1.11,它要的 @vitest/utils@4.1.11 上游根本沒發。)tauri-wd 生出 debug app,外掛看到 /Applicationsexit(0)——沒有任何輸出。webdriver 端只App did not report plugin port in time,一個完全指錯方向的訊息。第 4 條的修法是:e2e-webdriver feature 下不註冊 single-instance。自動化
build 必須能和使用者正在用的 app 並存;正式 build 不受影響。
修完之後,它第一次真的讀到畫面上的字:
Step 1 of 5 Welcome to Spectyn Mesh
E2E 一跑起來就看到「Signed in as ...(google)」——它在操作者的真實狀態上跑。tauri-wd 生出 app,子行程繼承環境,而桌面版用 dirs::home_dir() 決定狀態位置。
第一次修:讓 tauri-wd 帶著沙箱 HOME 啟動。首屏果然變回「Sign in with
Google or Apple」,乾淨機器的樣子。
然後第二次跑,首次啟動那組斷言又紅了——擦掉 HOME 沒有把 localStorage 擦掉。
原因是 macOS 的 WKWebView 不看 $HOME,它用 NSHomeDirectory()(讀 passwd 條目)。
所以真正的儲存區在:
~/Library/WebKit/ai.spectynmesh.app ← 已安裝的 .app,mtime 停在 8月12(沒被碰)
~/Library/WebKit/spectyn-mesh-app ← debug binary(沒有 bundle,用執行檔名當 key)
兩個是分開的。這也修正了我先前的判斷:我原本以為測試污染到了使用者
已安裝那份,mtime 證明沒有。要擦的是 debug 自己那份,而這正是「首次啟動」
失真的原因——第二次以後,那組斷言測到的根本不是首次。
值得記一筆的是這個 bug 的方向:它讓斷言變紅,所以被抓到了。如果當初
斷言寫成反向(例如「不該出現登入牆」),同一個殘留狀態會讓它靜靜地一直綠。
斷言全部照著探針從活視窗讀回來的真實文字寫,不是照理想寫的:
✓ first run lands on onboarding step 1
✓ Continue is gated until an identity exists
✓ system self-check runs after onboarding
✓ self-check admits the missing provider on a fresh machine
✓ shell exposes the "Spectyn" / "Mesh" / "Setting" surface
✓ Spectyn surface has 今日 / 對話 / 記錄 / 回顧 / 目標
✓ Mesh surface renders 終端主控台 / Spectyn 終端 / 能力 / 審核
✓ Setting surface opens API 金鑰,四家免費 provider 都在
✓ key rows name the env var rather than a value
✓ window survived the whole walk
NATIVE-E2E RESULT: PASS
最後一條 key rows name the env var rather than a value 是刻意放的:設定頁
只該顯示 GROQ_API_KEY 這種環境變數名,不該把 key 本體畫出來。
母規則照做:把 vite 殺掉再跑一次,走查整片倒下(FAIL),不是靜默通過。
再加上開發過程中那四條「首次啟動」的紅→綠,這批斷言每一條都見過紅。
操作者接著問:「ollama、vllm,或這類本地的可能也能自動偵測跟串接」。
盤點現況比想像難看:
| 狀況 | |
|---|---|
| Ollama | 480 處引用,偵測、路由、embedding 都有 |
| LM Studio | 19 處,有預設 base_url,偵測得到但 onboarding 不採用 |
| Lemonade | 同上 |
| vLLM | 1 處——只是一個字串 |
| llama.cpp / Jan / LocalAI | 有 provider type,沒有偵測 |
而且真正的斷點不在偵測,在串接。三個獨立的洞,任何一個都足以讓
「偵測到的本地引擎」變成擺設:
原本的候選表是 (slug, url),:8000 標成 lemonade。問題是熱門埠會撞:
| 埠 | 誰在用 |
|---|---|
| 8000 | vLLM、Lemonade |
| 8080 | llama.cpp server、LocalAI、MLX |
| 1234 | LM Studio |
「有東西回應 :8000,所以它是 vLLM」是一句系統後續會照著行動的謊——
它決定寫哪種 provider type、用什麼 prompt 格式。
改法:probe 成功之後多一步辨識,問伺服器它是誰(Ollama 的 /api/tags、
vLLM 的 /version、llama.cpp 的 /props、/models 裡的 owned_by)。
辨識不出來就誠實回報 openai-compat,不猜。每筆結果都帶著 evidence
字串說明名字是怎麼來的,UI 才有辦法區分「偵測到」和「依埠推測」。
只有在該埠不與任何已知引擎相撞時(11434 / 1234 / 1337 / 13305 / 5001 / 5000),
埠號本身才算證據。這條規則自己也有測試看著:候選表若哪天讓某個
「不撞埠」變成撞埠,every_unambiguous_port_is_actually_unique_in_the_table
就會紅。
順帶把序列 probe 改成並行。九個候選 × 1.5 秒逾時 = 13.5 秒的乾等,
而「什麼都沒跑」正是最常見的情況。並行之後整趟只花一次逾時,
本機實測 0.02 秒。
let has_ollama = local.iter().any(|s| s.name == "ollama");
if has_ollama { ranked.push("local-ollama"); }
偵測器辛苦找到 LM Studio,回傳,然後這一行把它丟掉。跑著 vLLM 的機器
會被告知「請去設定一個 provider」——設定他已經在跑的那個。
這個最隱蔽。with_local_first 正確地把偵測到的 base_url 寫進 agents.toml,
但真正發請求的 provider_base_url 是這樣的:
fn provider_base_url(slug: &str, default_base: &str) -> String {
let key = format!("SPECTYN_MESH_{}_BASE_URL", slug.to_ascii_uppercase());
std::env::var(&key).ok()...unwrap_or_else(|| default_base.to_string())
}
只讀環境變數。而解析 agents.toml 的 ProviderTomlSection 連 url
欄位都沒有宣告——它只認 default_model 和 models。
也就是說:偵測 → 寫檔 → 呼叫端拿寫死的預設值。一台把 Ollama 跑在 11500 的
機器,會被正確偵測、正確記錄,然後對著 11434 發請求。
改法是把三層接起來:env → agents.toml 的 url → 編譯期預設。其中一個細節
值得記:偵測記下來的是 http://host:11434/v1(因為那是 /models 回應的位址),
而呼叫端會再接 /v1/chat/completions,直接用就變成 /v1/v1/...。所以中間
要正規化,而這件事也有它自己的測試。
還有 provider type:原本不管偵測到什麼,一律寫 type = "ollama"。
偵測到 vLLM,block 名字叫 vllm,type 卻是 ollama,而 type 才是決定走哪條
完成路徑的東西。改成 Ollama 走自己的 wire,其餘一律 llamacpp
(就是通用的 OpenAI 相容本地路徑)。
兩組都做了拆橋紅測:
identify 退回「純靠埠號」→ ambiguous_port_with_no_evidence_is_not_guessed 紅provider_base_url 的 toml 那層拆掉 → detected_url_in_agents_toml_reaches_the_wirea_type_match_beats_a_block_merely_named_after_the_slug 兩條紅還原後全綠。相關模組回歸:config 150、providers_wire 70、onboarding_wire 36、
providers 181、agent 65,全部 0 failed。
本機活體偵測:
detected 1 local server(s):
ollama@http://127.0.0.1:11434/v1 (3 models) [/api/tags responded]
括號裡那句 [/api/tags responded] 就是重點——這個名字有出處。
操作者問:「spectyn cli 有辦法透過 computer use 或其他的方式,實際的測試
terminal 上面運作的表現嗎?」
computer use 可以,但那是最差的選項:慢、不穩、而且看到的是像素。終端有更硬的
測法——開一個真的偽終端(PTY),把出貨的 binary 丟進去跑,再把輸出餵進
VT100 模擬器,斷言「螢幕上第幾列印了什麼」。
盤點現況:src/tui.rs 有 10,687 行、125 個 #[test],用的是 ratatui 的TestBackend。那些測的是 widget 樹:給定這個狀態,buffer 裡有這些字。
它們看不到真實終端層做的任何事,因為 TestBackend 就是拿來取代那一層的。
盲區包括:
管線式的 Command::output() 測試同樣看不到:管線不是終端,binary 會走
另一條分支,測到的是沒出貨的那條。
portable-pty 本來就在相依裡(給 agy 的混合 CLI adapter 用),但沒有任何
測試用它驅動過 CLI。加上 vt100 當 dev-dependency,寫成tests/pty_terminal_behaviour.rs,六條斷言:
✓ tui_paints_a_screen_inside_a_real_pty
✓ tui_enters_the_alternate_screen
✓ repl_prints_a_prompt_and_accepts_a_line
✓ repl_with_a_config_does_not_run_first_time_setup
✓ help_emits_no_color_when_stdout_is_a_pipe
✓ help_text_is_the_same_on_a_pty_as_through_a_pipe
最後兩條是一對,缺一不可:第一條保證管線輸出不夾 ANSI(不然就變成消費端
資料裡的垃圾),第二條保證 PTY 上的說明內容相同——否則第一條守的是
一條沒人走的路。
坑 1:乾淨家目錄會叫醒精靈,而精靈會在區網開 daemon。
首次設定的閘是「沒有 config 而且 stdin 是 tty」——PTY 測試天生同時滿足。
於是第一版測試一跑,精靈就真的動手了:
✓ ed25519 identity created
✓ started `spectyn serve` (pid 11608) — mDNS advertising on the LAN
測試不可以在別人的網路上放 daemon,而且每跑一次就多留一個。
解法是預先放一份 config 讓精靈不必啟動,並且把這件事寫成斷言而不是
默默避開——repl_with_a_config_does_not_run_first_time_setup 會檢查畫面上
沒有 started \spectyn serve``,也檢查沒有生出不請自來的 identity。
坑 2:我冤枉了 /exit。
第一版測試在 /help 之後立刻送 /exit,然後報告「REPL 沒有退出」。
差點就當成產品 bug 記下來。用 PTY 探針分別測三條路徑:
[/exit] exited=true
[ctrl-d] exited=true
[/quit] exited=true
全部正常。錯的是測試太急:REPL 的提示符下面還有一行狀態列,
所以「最後一行是提示符」這個就緒判定根本是假的。改成等畫面靜止——
連續 600ms 沒有任何一格改變才送下一個鍵。順帶一提,修好之後整支測試
從 33 秒掉到 4.5 秒,因為先前那 33 秒幾乎都是在等逾時。
坑 3:PTY 上的 help 是彩色的。
拿 raw byte stream 去比對純文字,永遠對不上——TTY 上 help 帶 SGR 序列。
這正是 VT100 模擬器存在的意義:讓它把 escape 播完,回傳「人會讀到的字」。
另外把測試用的螢幕開成 200 列,help 才不會把開頭捲掉。
追坑 1 的時候,同一次執行裡出現了自相矛盾的兩句話:
✓ ed25519 identity created
...
✗ device key [NO_LOCAL_IDENTITY]: no ~/.spectyn-mesh/identity.key on this device
根因:setup 寫檔走 cli_config::spectyn_data_dir(),那個函式有看SPECTYN_HOME;而 doctor 把作業系統家目錄傳進 diagnose(),裡面spectyn_dir(home) 直接 home.join(".spectyn-mesh"),完全不看SPECTYN_HOME。兩個解析器對「資料根在哪」看法不同。
所以只要設了 SPECTYN_HOME(每個 hermetic 測試、任何這樣設定的 service
unit),doctor 就會對一台身分好端端的機器謊報 NO_LOCAL_IDENTITY。
跟先前 agy 評審抓到的「信任庫繞過解析器」是同一類。
修法用現成的 seam:diagnose 本來就收一個 env_get 閉包,讓spectyn_dir 透過它去問 SPECTYN_HOME,函式維持純粹、兩條分支都可測。
順手把訊息裡寫死的 ~/.spectyn-mesh/identity.key 改掉——在 SPECTYN_HOME
情境下那句話本身就是錯的。
三條測試:設了 SPECTYN_HOME 且有 key → 綠;設了但空的 → 仍然紅
(還故意在 OS 家目錄放一把誘餌 key,確認不會被誤採);沒設 → 舊行為不變。
把修改退回舊寫法,前兩條紅、第三條綠——正是預期的分佈。
tui_paints_a_screen_inside_a_real_pty 紅tui_enters_the_alternate_screen 紅spectyn_dir 退回不看 env → diagnostics 兩條紅、第三條照樣綠其中第二次紅測我第一次用 sed 改,改失敗了而測試照樣綠——差一點就
把「沒改到」當成「證明了」。改用會先驗證錨點存在的腳本重做,才真的看到紅。
綠燈本身不是證據,能紅才是。
回到操作者先前裁定的另一半:第一頁硬鎖在 Google/Apple 登入。
我原本以為這是要「新增一個本地模式」。翻到後端才發現不是——是前端擋了一道
後端明說不該存在的牆。
onboarding_wire.rs 的狀態機表:
"fresh_install:forward" => SetProvider, // talk-first: provider first
進 SetProvider 的前置條件:
SetProvider | FirstReplyReceived => {
if ctx.provider_slug.is_some() || ctx.demo_relay_used { Ok(()) }
else { Err(OnboardingError::NoProviderConfigured) }
}
要的是 provider,不是登入。而且同一個檔案裡躺著一條測試:
#[test]
fn advance_fresh_install_does_not_require_login() {
// TALK-FIRST: a provider with NO login/identity still advances — login
// is a later soft-prompt, never a gate.
前端那行 (current === "fresh_install" && !loginIdentity) 旁邊的註解說,
後端會把 login 折進 fresh_install → created_identity 這條邊——那條邊
早就不在活路徑上了。註解描述的是一份過期的契約,而程式碼照著它擋人。
而 provider_slug 是誰填的?後端自己的偵測(perform_provider_detection)。
也就是說,今天早上修好的那個本地引擎偵測,正好就是這道牆不需要存在的原因:
一台已經在跑 Ollama / vLLM / LM Studio 的機器,本來就有 provider。
第一版我把閘改成「偵測還在跑就先鎖著」:
(current === "fresh_install" && !providersLoaded) ||
看起來合理,實際上更糟。因為載入 provider 的那個 effect 開頭是:
if (current !== "set_provider" || providersLoaded) return;
它根本不在第一步跑。所以 providersLoaded 在第一頁永遠是 false,
Continue 永遠鎖著——把「必須登入」換成一個永遠不會結束的「偵測中…」。
前端測試當場抓到了(does not require a sign-in to leave the first screen 紅)。
正確答案是第一頁不設閘:真的什麼都沒有時,後端會回NoProviderConfigured,而這個畫面本來就會把後端錯誤顯示出來。誠實的錯誤
勝過猜出來的鎖。
順著改下去,發現「只認 ollama」在三個地方各寫了一遍:
| 位置 | 寫法 |
|---|---|
onboarding_wire.rs |
local.iter().any(|s| s.name == "ollama") (今天早上修了) |
onboarding-hello.tsx |
servers.some((s) => s.name === "ollama") |
onboarding_config.rs |
無條件寫死 [providers.local-ollama] / type="ollama" / :11434 |
第三個最扯:write_onboarding_config 的參數就是 Option<&LocalServer>
——名字和 base_url 一直都在手上,三行硬編碼把它們全丟了。偵測到 vLLM,
寫下去是 Ollama;Ollama 跑在 11500,寫下去是 11434。
改成依偵測結果決定 block 名、type、url;什麼都沒偵測到時,保留原本那個
「永遠附一條 local-ollama 尾巴」的刻意設計。
前端 10 條(3 條新增)、core 三個模組回歸(onboarding_config 6 / config 153 /
onboarding_wire 36 / providers 181)、app 全量 vitest 118 檔 661 條全綠。
母規則:把登入牆裝回去,does not require a sign-in to leave the first screen
變紅,其餘 9 條照常綠——正是預期的分佈。
最後在真實原生視窗裡跑一次走查,把先前那條刻意記錄舊行為的斷言翻過來:
✓ first run still offers Google/Apple sign-in
✓ first run says sign-in is optional
✓ Continue is reachable without signing in
NATIVE-E2E RESULT: PASS
當初那條 Continue is gated until an identity exists 寫下來的時候就註明了
「當本地路徑落地,這一行就是告訴我們牆真的移開了的那一行」。它做到了。
L8 有兩半:終端還給使用者了嗎、有沒有留下孤兒行程。
先讀退出路徑,結果是好的:
let result = run_loop(...).await; // 注意:沒有 `?`
...
restore_terminal(&mut terminal)?;
result
run_loop 回傳 Err 也會先還原再把錯誤往上丟。兩條 PTY 測試證實正常退出
(Ctrl-D)確實送出離開 alternate screen 的序列,也沒有留下任何以它為父行程的
子行程。
然後我想到第三條路:panic。panic 會直接 unwind 掠過那行 restore_terminal。
我 grep 了 set_hook,tui.rs 和 bin/spectyn.rs 都沒有,於是下了結論:
「TUI 一旦 panic,使用者的終端就留在 raw mode + alternate screen」,
加了一個 panic hook,寫了測試,綠了。
然後母規則救了我。
把 hook 拿掉重跑——測試照樣綠。空綠。
追下去:把 raw byte stream 的序列數印出來,有 hook 時 leave1049=2,
沒有 hook 時 leave1049=1。沒有我的 hook,終端還是被還原了。
誰做的?我第一次 grep 只搜了 tui.rs 和 bin/spectyn.rs。搜整個 crate:
src/diag.rs:106: std::panic::set_hook(Box::new(move |info| {
write_crash_log(info);
// Make sure the terminal is sane after a TUI panic — both
// alternate-screen and mouse-capture have to be turned off
// explicitly because crossterm's raii guards skip on panic.
restore_terminal_after_panic();
早就有人做過了,而且註解裡連原因都寫清楚了(crossterm 的 RAII guard
在 panic 時不會跑)。我加的是一段重複的程式碼,它的文件註解還宣稱修好了
一個不存在的 bug——比不加更糟。
撤掉。但測試留下,因為它守的東西是真的:restore_terminal_after_panic()
存在很久了,卻從來沒有任何測試從真實終端證明它有效。這支測試補的是那個
空缺,而不是我以為的那個 bug。
紅測的靶因此也換了:停用 diag.rs 裡那行 restore_terminal_after_panic(),
測試立刻紅,訊息是「a panic left the terminal in the alternate screen」。
這次是真的紅測。
搭配的還有 SPECTYN_TUI_PANIC_PROBE(僅 debug build),讓 TUI 在第一次繪製
之後蓄意 panic——要驗證還原,得先真的把終端弄髒。
今天已經是第二次了:綠燈不是證據,能紅才是。上一次是 sed 改失敗而測試
照樣綠,這一次是我根本沒找到既有的實作就動手加。兩次都是母規則抓到的,
兩次都不是靠讀程式碼想出來的。
還有一個小教訓:grep 的範圍就是你結論的範圍。我搜了兩個檔案就宣稱「沒有
panic hook」,而它躺在第三個檔案裡。
L8 最終:9 條 PTY 測試全綠;core lib 全量 2665 passed / 0 failed;
playwright 32 passed。
到這裡為止,今天修的每一個問題都有一個共同形狀:不是功能沒寫,是某個地方
悄悄說了謊。既然形狀相同,就該問:同樣的形狀,庫裡還有幾個?
開了一個 16 路稽核,每一路對應今天實際踩到的一種缺陷:SPECTYN_HOME 繞過、
寫死端點、只認單一引擎、stderr 汙染 stdout、退出碼說謊、空綠測試、前端擋著
過期契約、Windows 生命週期、測試污染真實環境、終端狀態、金鑰外洩、
LAN 副作用、棘輪失效、對使用者說謊、錯誤吞噬、死程式碼。
每一路的發現再交給一組獨立的懷疑者,任務是駁倒它——預設 refuted=true,
只有能說出「哪個真實操作者會怎麼被害」才算成立。
80 agents · 4,946,829 tokens · 30 分鐘
176 個發現 → 64 條經過驗證 → 46 條成立、18 條駁回
駁回率 28%。這個數字比成立數更重要:它代表懷疑者真的在擋假陽性,而不是
橡皮圖章。
第一批結果裡有一條標著 OUT-OF-DIMENSION:
the Tauri app crate does not compile on this branch —
error[E0063]: missing field \evidence` in initializer of `LocalServer``
我早上給 LocalServer 加 evidence 欄位時,只修了 core 端的建構點,漏掉app/src-tauri/src/commands/onboarding.rs:422。我自己沒發現,因為我只跑了cargo build --bin spectyn,從來沒跑過 app crate。
而且同一行還有個既有的 bug:name 寫死 "ollama"。寫檔器正是靠這個欄位
決定 block 名與 type ——所以 GUI 這條路不管偵測到什麼引擎,寫下去都是 Ollama,
等於把我早上剛修好的 write_onboarding_config 餵假話。
未驗證的那 112 條裡,有一條說:
三處手抄的免金鑰清單都漏了
llamacpp
追下去全中。兩個設定寫檔器對非 Ollama 的本地引擎一律寫 type = "llamacpp",
而:
agent.rs:1524 與 :2316 的 runtime 金鑰閘寫的是"ollama" | "lmstudio" | "lemonade" —— 一個偵測到的 vLLM 會被本專案自己的diagnostics.rs 的 NO_KEY_TYPES 同樣缺,doctor 會把正常運作的 vLLM 報成onboarding_default_model 用 slug 白名單比對,但 live path 傳進去的一律是local- 前綴的 block 名,所以只有 local-ollama 會中。local-vllm 落到_ 分支,拿到 default_model = "claude-sonnet-4-5-20251022" ——也就是說,我早上那個「本地引擎自動偵測與串接」的修法,會產出一份跑不起來的
設定。三處清單各自都是對的,湊在一起就是壞的,而沒有任何一個測試同時看著
這三處。
改成單一判定 is_local_keyless(),四處共用,並用候選表本身當測試資料:
「這個偵測器能回報的每一個引擎,都必須被判定為免金鑰」。新增引擎不可能再
漏掉某一份清單。
第二個 workflow 讓 16 個規劃者各自負責一群互不重疊的檔案,產出精確到可以
機械套用的補丁:old 必須是檔案裡逐字存在且唯一的字串。每份計畫再交給一個
審查者,任務只有一個——打開每個檔案,確認每個錨點真的存在且只出現一次。
32 agents · 2,728,334 tokens · 29 分鐘
16 份計畫 · 141 個編輯 · 49 條測試 · 全數通過審查
套用前先乾跑一次驗證所有錨點:141/141 命中。這不是運氣,是因為審查那一階段
專門在防這件事。
一、p0_7_no_boot_network_static 紅了。 這條守的是「冷啟動路徑不得含
任何遠端位址」。我加的 OLLAMA_HOST 支援用 format!("http://{}", h) 正規化
操作者給的位址,掃描器抓到 http:// 就報。
兩個選擇:放寬守衛,或改我的程式。都不對。真正的不變量是「編譯進去的
候選表不得含非 loopback 位址」——操作者自己設的環境變數是他的明確指令,
不是硬編的遠端位址。所以把守衛從字面掃描改成結構性判定:直接解析CANDIDATES 表,逐條要求 loopback,並要求表的規模不得無故縮水
(否則守衛會在守一個空集合)。
紅測:在候選表塞一個 http://example.com → 紅。
同一條測試還抓到 http://box:8000 ——那是我自己測試裡的假資料。守衛的
價值就在嚴格,所以改假資料而不是放寬守衛。
二、我做了一次無效的紅測,差點當成證明。 修完之後我跑紅測,看到 FAIL 就
準備收工——但那條測試本來就是紅的,我的注入根本沒被驗證到。今天第三次了。
重做:先確認測試是綠的,再注入,才叫紅測。
三、兩條計畫寫的 PTY 測試前提不成立。 它們假設spectyn login email 會停在密碼提示等輸入;實測即使在真 pty 下它也會直接
退出。修法本身(錯誤路徑要還原 echo)是對的且已套用,但這兩條測試證明不了它。
不留紅,也不用 #[ignore] 掩蓋——那正是這次稽核抓到的空綠模式之一。
移除,並在檔案裡寫下:這兩個修法已套用但目前未被證明,以及要證明它需要
一個真的會阻塞在隱藏輸入的指令。把未證明留成看得見的字,比留成一條永遠不跑
的測試誠實。
把測試從 cargo test -- --test-threads=1 換成 cargo nextest run:
lib 2,669 條: 84.8s → 9.9s (8.5×)
快是因為 nextest 每個測試獨立行程,不需要為了 env 互擾而序列化。
但全量一跑,兩條測試紅了,而且都不是回歸:
search_fts5_p99_latency_under_200ms_with_1000_rows —— 一條延遲測試。smoke_isolation —— 它斷言「操作者的真實資料根前後位元相同」。平行跑時我一度把延遲那條歸因到自己的改動(還原 cli_config.rs 就綠了)。那是單一
樣本的巧合——帶著改動連跑五次全過。差一點就為了一個不存在的回歸去改
正確的程式碼。
正確的修法不是放寬預算(那等於悄悄退掉保證),是讓「主體就是時間」的測試
獨佔機器。test-group 只序列化群組內部,外面三千多條照跑;threads-required = 'num-cpus' 才是真正的獨佔。
最後的狀態很誠實:延遲那條就算獨佔機器,TRY 1 仍以 12.63 秒紅、TRY 2 以
12.67 秒綠。時間幾乎一樣,是單一離群樣本翻的——這條測試本質上就在邊緣,
retry 遮住了它,而不是修好了它。記在這裡,而不是假裝它綠。
core lib 2,669 條 9.9s
core 全量 3,413 條 93s (1 flaky)
前端 vitest 672 條 119 檔
原生視窗 E2E 29 條 PASS
macOS bundle 5 條 PASS + 1 informational
以及一個沒人量過的稅:
core/target 55 GB / 729,755 檔 → 13 GB / 24,188 檔
cargo no-op 24.2s → 10.5s
deps/ 裡積了 636,449 個 .rcgu.o,spectyn_mesh 一個 crate 就有 23 組
hash(22 組是死的),加上 26 GB 的 incremental 和 7,295 個改名前的 phantom_*。
每次 cargo 呼叫——包括什麼都沒改的 no-op——都要 stat 過這堆,12 秒的
sys time 就是這樣燒掉的。我今天跑了幾十次 cargo,光這個稅就是十幾分鐘。